Skip to content

openapi3: store field locations in a slice, not a map - #25

Draft
reuvenharrison wants to merge 6 commits into
masterfrom
perf/origin-fields-slice
Draft

openapi3: store field locations in a slice, not a map#25
reuvenharrison wants to merge 6 commits into
masterfrom
perf/origin-fields-slice

Conversation

@reuvenharrison

@reuvenharrison reuvenharrison commented Aug 1, 2026

Copy link
Copy Markdown

Why

Origin.Fields is map[string]Location. A collection carries a handful of scalar fields, but a Go map allocates a whole bucket per collection whatever it holds, so these maps dominate the retained size of a parsed document.

Profiling a 16.8 MB spec loaded with IncludeOrigin, originFromSeq (where the inserts happen) allocated 339 MB, 13.9% of the total. With the slice it drops out of the top six entirely.

What

FieldLocations, a slice in document order. Each Location already carries its Name, so the lookup key costs nothing extra, and a linear scan beats a map at these sizes.

  • Get(name) returns a location or the zero value; Lookup(name) also reports presence.
  • MarshalJSON / UnmarshalJSON keep the serialized shape a name-keyed object, so output is unchanged.
  • Lookup is a hand-written loop rather than slices.IndexFunc on purpose: the closure does not inline, so IndexFunc pays a call per element. Measured on 3/6/12 fields it is 5-100% slower on a hit and 2-3x slower on a miss, and misses dominate (most fields carry no recorded location). A comment says so, to save the next reader from "modernising" it.
  • sort.Slice becomes slices.SortFunc at the two name-ordering sites, since sort.Slice swaps through reflection.
  • A small copy is removed: recordMapKeyLocations built a whole *Origin purely to read its Fields, then copied that slice into a second one and discarded the rest. Nothing else holds it, so it is sorted in place and handed over.
  • Sequences deliberately stays a map; the reasoning is on the Origin type.

Results

16.8 MB spec, IncludeOrigin, median of 3 (peak from 7 runs):

peak RSS total allocated retained
before 933 MB 2401 MB 478 MB
after 918 MB 2176 MB 253 MB
-1.6% -9.4% -47%

Retained is the target. A differ holds two documents resident for the duration of a comparison, so this halves the floor of the whole operation rather than of one load.

originFromSeq is the second-largest allocator, not the largest. The largest is the JSON decode, a separate concern tracked in getkin#1236.

Breaking change

Origin.Fields changes type, so origin.Fields[name] no longer compiles. The mechanical replacement is origin.Fields.Lookup(name), same (Location, bool) shape. for range over Fields and len(Fields) are unaffected.

For scale: adapting oasdiff, a heavy consumer of this API, took 5 call sites and 13 test fixtures.

@reuvenharrison
reuvenharrison marked this pull request as draft August 1, 2026 21:39
reuvenharrison added a commit to oasdiff/oasdiff that referenced this pull request Aug 1, 2026
Must be removed before merge. This PR adapts to Origin.Fields becoming a
slice, so it cannot compile against any published kin until #25 lands
upstream and is released. Without the pin CI is red for a reason that
says nothing about whether the adaptation is correct; with it, a green
run means exactly that.
reuvenharrison and others added 4 commits August 2, 2026 13:39
Origin.Fields was map[string]Location. A collection carries a handful of
scalar fields, but a Go map allocates a whole bucket per collection
whatever it holds, so the map dominated the retained size of a parsed
document: on a 22 MB spec, inserting into it accounted for 294 MB of the
715 MB retained, and the maps themselves for another 22 MB.

Replace it with FieldLocations, a slice in document order. Each Location
already carries its Name, so the lookup key costs nothing extra, and
lookup is a linear scan, which beats a map at these sizes.

Retained document size drops 47% at both sizes measured:

  5 MB spec:   131 MB -> 69 MB
  22 MB spec:  536 MB -> 282 MB

Peak heap during load is unchanged, since it is dominated by the
transient yaml parse tree rather than by what is retained.

Get(name) returns a location or the zero value; Lookup(name) also
reports presence. MarshalJSON and UnmarshalJSON keep the serialized
shape a name-keyed object, so output is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
recordMapKeyLocations built a whole *Origin -- key Location, fields
slice, sequences map -- purely to read its Fields, then copied that
slice into a second one and discarded the rest. Nothing else holds the
slice, so it can be sorted in place and handed over directly, removing
one allocation per scalar-valued map.
./docs.sh output is checked in and verified by CI's git diff --exit-code;
changing Origin.Fields's type and adding Get/Lookup changed the public
API surface.
sort.Slice swaps through reflection; slices.SortFunc is generic and the
package is already used elsewhere in the repo. Both call sites are
name-ordering, one on the load path (recordMapKeyLocations) and one on
the JSON round-trip.

Lookup deliberately stays a hand-written loop. slices.IndexFunc reads
better but its closure does not inline, so it pays a call per element:
measured on 3/6/12 fields it is 5-100% slower on a hit and 2-3x slower
on a miss, and misses dominate here since most fields carry no recorded
location. The comment records that so it is not 'modernised' later.
@reuvenharrison
reuvenharrison force-pushed the perf/origin-fields-slice branch from 314575a to db614a9 Compare August 2, 2026 10:41
The obvious question on this change is why Fields moved off a map and
Sequences did not. Two reasons, now written where a future reader will
find them rather than only in the pull request.

FieldLocations can drop the map because Location.Name already carries
the key, so the map stored what the value repeated. In Sequences,
Location.Name holds the item's value (an enum member, a required
property) while the key is the field's name, so a slice would need a
wrapper type invented purely to hold it -- trading a plain slice of an
existing type for a slice of structs each holding a slice.

The memory argument is also far weaker: only a collection with a
sequence-valued field allocates one, measured at 5% of collections
(20,000 of 390,004) on a 16.8 MB spec, and a nil map costs nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant